<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>发送端</title>
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <style>
    :root{--bg:#fafafa;--card:#fff;--muted:#888;--border:#e6e6e6}
    body{font-family:system-ui,Segoe UI,Roboto,"Helvetica Neue",Arial;padding:16px;background:var(--bg);color:#222}
    h3{margin:0 0 12px}
    .controls{display:flex;gap:8px;align-items:center;margin-bottom:10px}
    video{max-width:100%;background:#000;margin-top:8px;border-radius:6px;display:block}
    button{padding:6px 12px;border-radius:6px;border:1px solid #ccc;background:#fff;cursor:pointer}
    button.secondary{background:#f6f6f6}
    #status{margin-left:8px;font-weight:600}
    .log-panel{margin-top:12px;border-radius:8px;border:1px solid var(--border);background:var(--card);overflow:hidden}
    .log-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #eee}
    .log-actions{display:flex;gap:8px;align-items:center}
    table.log-table{width:100%;border-collapse:collapse;font-size:13px}
    table.log-table thead th{background:transparent;padding:8px 10px;border-bottom:1px solid #eee;text-align:left;font-weight:700}
    table.log-table tbody td{padding:8px 10px;border-bottom:1px solid #f3f3f3;vertical-align:middle}
    .ksize{min-width:72px;text-align:left;color:#666;font-size:12px}
    .timestamp{color:var(--muted);font-size:12px;min-width:78px}
    .log-body{max-height:300px;overflow:auto}
    .status { font-weight:600; margin-left:8px }
    .stats { margin-left:12px; color:var(--muted); font-size:13px }
    .controls { flex:1; }
  </style>
</head>
<body>
  <h3>发送端</h3>

  <div class="controls">
    <input type="file" id="fileInput" accept="video/*">
    <button id="connectBtn">连接 WebSocket</button>
    <button id="startBtn" disabled>开始发送</button>
    <button id="stopBtn" disabled>停止发送</button>
    <div id="status" class="status">状态: 未连接</div>
    <div id="stats" class="stats">发送: 0 KB/s | 帧: 0</div>
  </div>

  <video id="video" controls></video>

  <div class="log-panel" aria-live="polite" style="margin-top:14px">
    <div class="log-header">
      <div><strong>日志</strong> <small style="color:#666;margin-left:8px">（最近 500 条，批量刷新）</small></div>
      <div class="log-actions">
        <button id="clearLogBtn" class="secondary">清空日志</button>
        <label class="small" style="display:flex;align-items:center;gap:6px">
          <input id="filterRecv" type="checkbox" checked> 显示发送帧日志
        </label>
      </div>
    </div>
    <div class="log-body">
      <table class="log-table" id="logTable" aria-label="日志表">
        <thead>
        <tr><th class="timestamp">时间</th><th style="width:86px">类型</th><th>消息</th><th class="ksize">大小</th></tr>
        </thead>
        <tbody id="logTbody"></tbody>
      </table>
    </div>
  </div>

  <script>
    (function(){
      // ---------- 配置 ----------
      const MAX_LOG_ENTRIES = 500;
      const LOG_FLUSH_MS = 150; // 批量更新日志频率
      const SEND_SUMMARY_INTERVAL = 500; // 合并发送统计写日志间隔

      // ---------- DOM ----------
      const fileInput = document.getElementById('fileInput');
      const video = document.getElementById('video');
      const connectBtn = document.getElementById('connectBtn');
      const startBtn = document.getElementById('startBtn');
      const stopBtn = document.getElementById('stopBtn');
      const status = document.getElementById('status');
      const stats = document.getElementById('stats');

      const logTbody = document.getElementById('logTbody');
      const clearLogBtn = document.getElementById('clearLogBtn');
      const filterRecv = document.getElementById('filterRecv');

      // ---------- LogManager（同 viewer） ----------
      class LogManager {
        constructor(tbody, opts={}) {
          this.tbody = tbody;
          this.max = opts.max || MAX_LOG_ENTRIES;
          this.queue = [];
          this.flushTimer = setInterval(()=>this.flush(), LOG_FLUSH_MS);
        }
        add(type, message, bytes=null, meta={}) {
          if (!filterRecv.checked && !meta.force) return;
          const now = new Date();
          this.queue.push({type, message, bytes, ts: now, meta});
        }
        flush() {
          if (this.queue.length === 0) return;
          const frag = document.createDocumentFragment();
          const toAppend = this.queue.splice(0, this.queue.length);
          for (let rec of toAppend) {
            const tr = document.createElement('tr');
            const tsTd = document.createElement('td');
            tsTd.className = 'timestamp';
            tsTd.textContent = rec.ts.toLocaleTimeString();

            const typeTd = document.createElement('td');
            typeTd.className = 'type-' + rec.type;
            typeTd.textContent = rec.type;

            const msgTd = document.createElement('td');
            msgTd.className = 'msg';
            msgTd.title = rec.message;
            msgTd.textContent = rec.message;

            const sizeTd = document.createElement('td');
            sizeTd.className = 'ksize';
            sizeTd.textContent = (typeof rec.bytes === 'number' ? formatKB(rec.bytes) : '');

            tr.appendChild(tsTd); tr.appendChild(typeTd); tr.appendChild(msgTd); tr.appendChild(sizeTd);
            frag.appendChild(tr);
          }
          this.tbody.appendChild(frag);
          while (this.tbody.children.length > this.max) {
            this.tbody.removeChild(this.tbody.firstChild);
          }
          console.debug('Flushed', toAppend.length, 'logs');
        }
        clear() { this.queue = []; this.tbody.innerHTML = ''; }
        destroy(){ clearInterval(this.flushTimer); }
      }

      function formatKB(bytes) {
        if (typeof bytes !== 'number' || isNaN(bytes)) return '';
        if (bytes >= 1024*1024) return (bytes / (1024*1024)).toFixed(2) + ' MB';
        return (bytes / 1024).toFixed(1) + ' KB';
      }

      const logManager = new LogManager(logTbody);

      clearLogBtn.onclick = () => logManager.clear();

      // ---------- WebSocket ----------
      let ws = null;
      function connectWs(){
        const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
        const url = scheme + '://' + location.host + '/video/sender';
        ws = new WebSocket(url);
        ws.binaryType = 'arraybuffer';

        ws.onopen = () => {
          status.textContent = '状态: 已连接';
          logManager.add('INFO', 'WebSocket 已连接到 /video/sender', null);
          startBtn.disabled = false;
        };
        ws.onmessage = (ev) => {
          if (typeof ev.data === 'string') {
            logManager.add('INFO', '[WS 文本] ' + ev.data, null);
          } else {
            logManager.add('WARN', '[WS 二进制] sender 收到非预期二进制数据', ev.data ? ev.data.byteLength : null);
          }
        };
        ws.onclose = () => {
          status.textContent = '状态: 已关闭';
          logManager.add('INFO', 'WebSocket 已关闭', null);
          startBtn.disabled = true; stopBtn.disabled = true;
        };
        ws.onerror = (e) => {
          status.textContent = '状态: 错误';
          logManager.add('ERROR', 'WebSocket 错误: ' + (e && e.message ? e.message : JSON.stringify(e)), null);
        };
      }

      connectBtn.onclick = () => { if (!ws || ws.readyState !== WebSocket.OPEN) connectWs(); };

      // ---------- file handling ----------
      const offscreenCanvas = document.createElement('canvas');
      const offCtx = offscreenCanvas.getContext('2d');

      fileInput.addEventListener('change', () => {
        if (fileInput.files && fileInput.files[0]) {
          video.src = URL.createObjectURL(fileInput.files[0]);
          video.play().catch(()=>{});
          logManager.add('INFO', '选择了本地视频: ' + fileInput.files[0].name, fileInput.files[0].size);
        }
      });

      // ---------- 发送逻辑（capture & send） ----------
      let sending = false;
      let fallbackInterval = null;

      async function sendFrameBlob(blob) {
        if (!ws || ws.readyState !== WebSocket.OPEN) { logManager.add('WARN','WebSocket 未打开，无法发送'); return; }
        try {
          ws.send(blob);
          // 不为每一帧都写日志，改为统计汇总
          // 但在调试模式可打开逐帧日志
          // logManager.add('SEND', '发送帧（Blob）', blob.size);
          return blob.size;
        } catch (e) {
          logManager.add('ERROR', '发送失败: ' + e);
        }
      }

      async function captureAndSend() {
        if (!ws || ws.readyState !== WebSocket.OPEN) return 0;
        if (video.readyState < 2) return 0;
        const w = video.videoWidth, h = video.videoHeight;
        if (!w || !h) return 0;
        offscreenCanvas.width = w; offscreenCanvas.height = h;
        offCtx.drawImage(video, 0, 0, w, h);
        return new Promise(resolve=>{
          offscreenCanvas.toBlob(async (blob)=> {
            if (!blob) return resolve(0);
            const size = await sendFrameBlob(blob);
            resolve(size || 0);
          }, 'image/jpeg', 0.75);
        });
      }

      // requestVideoFrameCallback loop + fallback
      function rvfcLoop(now, metadata) {
        if (!sending) return;
        captureAndSend().then(()=>{ /* no-op */ });
        try { video.requestVideoFrameCallback(rvfcLoop); } catch(e) { /* 浏览器不支持或抛错，fallback 走 setInterval */ }
      }

      // 汇总统计（每 INTERVAL 写入一次日志）
      let sendCount = 0, sendBytes = 0;
      let lastSummaryAt = performance.now();

      const SEND_STATS_INTERVAL = SEND_SUMMARY_INTERVAL;
      setInterval(()=>{
        const now = performance.now();
        const elapsed = Math.max(0.001, (now - lastSummaryAt) / 1000);
        const kbps = (sendBytes / elapsed) / 1024;
        stats.textContent = `发送: ${kbps.toFixed(1)} KB/s | 帧: ${sendCount}`;
        if (sendCount > 0) {
          // 写一条汇总日志而不是每帧写
          logManager.add('SEND', `已发送 ${sendCount} 帧，带宽 ${kbps.toFixed(1)} KB/s，最近帧大小 ${formatKB(Math.round(sendBytes / sendCount))}`, sendBytes, {force:false});
        }
        // reset counters
        sendCount = 0; sendBytes = 0; lastSummaryAt = now;
      }, SEND_STATS_INTERVAL);

      startBtn.onclick = () => {
        if (!ws || ws.readyState !== WebSocket.OPEN) { alert('请先连接 WebSocket'); return; }
        if (!video.src) { alert('请选择视频'); return; }
        sending = true;
        startBtn.disabled = true; stopBtn.disabled = false;
        status.textContent = '状态: 发送中';
        logManager.add('INFO', '开始发送帧', null);
        video.play().catch(()=>{});
        if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
          try {
            video.requestVideoFrameCallback(rvfcLoop);
          } catch(e) {
            // fallback
            fallbackInterval = setInterval(async ()=>{
              const size = await captureAndSend();
              if (size) { sendCount++; sendBytes += size; }
            }, 100);
          }
        } else {
          fallbackInterval = setInterval(async ()=>{
            const size = await captureAndSend();
            if (size) { sendCount++; sendBytes += size; }
          }, 100);
        }

        // attach a lightweight wrapper to capture frame sizes and count
        // We do this in captureAndSend promise resolves inside fallback case; for rvfcLoop mode, captureAndSend handles size resolution
        // To keep code simple: we patch sendFrameBlob to increment counters on successful send
        const origSendFrameBlob = sendFrameBlob;
        sendFrameBlob = async (blob) => {
          const size = await origSendFrameBlob(blob);
          if (size) { sendCount++; sendBytes += size; }
          return size;
        };
      };

      stopBtn.onclick = () => {
        sending = false;
        if (fallbackInterval) { clearInterval(fallbackInterval); fallbackInterval = null; }
        startBtn.disabled = false; stopBtn.disabled = true;
        status.textContent = '状态: 已停止';
        logManager.add('INFO', '停止发送帧', null);
      };

      window.addEventListener('beforeunload', ()=>{ if (ws) ws.close(); logManager.destroy(); });
    })();
  </script>
</body>
</html>
